Skip to content

fix(service-analytics): compile the case-sensitive text family per SQL dialect, so a $contains read scope stops admitting rows it excludes on SQLite - #15790

Merged
os-warren merged 4 commits into
mainfrom
claude/issue-15684-analytics-like-case-exact
Sep 5, 2026
Merged

fix(service-analytics): compile the case-sensitive text family per SQL dialect, so a $contains read scope stops admitting rows it excludes on SQLite#15790
os-warren merged 4 commits into
mainfrom
claude/issue-15684-analytics-like-case-exact

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #15684

service-analytics' three SQL compilers emitted col LIKE ? ESCAPE ? for the case-SENSITIVE text family on every dialect. SQLite's LIKE folds ASCII case unconditionally, so on a SQLite datasource the query's own where, the ADR-0021 D-C read scope and the /analytics/sql echo all answered rows the #4706 Q2 = A contract excludes. Measured on sql.js over the shared FILTER_TEXT_ROWS fixture: { name: { $contains: 'acme' } } returned ['1','2']ACME Corp and acme corp — where FILTER_TEXT_CASES says ['2']. On the read scope that is over-reach (#3948), the same reading read-scope-sql.ts already applied to its own LIKE escaping (#5567).

Head sha for every number below: dbf91caa8.

How the same semantics were reached without a driver dependency

The card asked this explicitly, so here is what was established rather than assumed.

1. A dialect-blind fix does not exist. Four candidates were checked and all fail:

candidate why not
GLOB everywhere SQLite-only; a syntax error on Postgres and MySQL
CAST(… AS BINARY) everywhere not a type on Postgres; takes NUMERIC affinity on SQLite
CAST(col AS BLOB) LIKE ? measured on the driver side to return NOTHING — SQLite's LIKE is false for a BLOB operand
the portable case-sensitive primitive replace() expresses "occurs somewhere", but not "at the start / at the end" without character-length arithmetic spelled differently on each dialect (LENGTH is bytes on MySQL; right() does not exist on SQLite; a negative substr start is not portable to Postgres)

So the dialect had to become an input — which is exactly the remedy like-pattern.ts's own header predicted ("two things would have to arrive together: a dialect input reaching these three compilers, and the per-dialect construct table").

2. The dialect arrives through the tier this package already uses for questions its compilers cannot answer. DatasetScopedStrategyContext.sqlDialect, declared beside #14079's declaredFieldType and tiered identically — a host that answers nothing keeps the LIKE it always had ("cannot answer, do not block"). It is filled by AnalyticsServicePlugin from IDataEngine.getDriverForObject, i.e. from the driver that will actually execute the statement, so no second dialect-resolution table exists to drift behind the driver's own knex spellings.

3. No fourth spelling, and no driver dependency. text-match-sql.ts re-emits #6518's construct table arm for arm through a caller-supplied bind callback, because textMatchPredicate is module-private, returns knex bindings, and lives in a package a service must not depend on. That is the identical arrangement like-pattern.ts already documents for escapeLikePattern versus applyLike — and the anti-drift mechanism is the same one: a test, not a comment. text-operator-case-exactness.test.ts runs the same FILTER_TEXT_CASES rows through a real SqliteWasmDriver (a devDependency, never a runtime one) on the same engine and requires the same row sets from both faces.

No runtime driver dependency was added. service-analytics' dependencies are unchanged: @objectstack/core, @objectstack/spec, @objectstack/types.

The one cross-package edit, named loudly

SqlDriver.dialectName moved from protected to public (packages/drivers/driver-sql/src/sql-driver.ts). It is a derived, read-only getter and no behaviour moves; it is what lets the analytics plugin read the executing driver's own answer instead of re-deriving one. All three SQL drivers extend SqlDriver, so driver-sql, driver-sqlite-wasm (which overrides isSqlite) and driver-turso (knex client better-sqlite3) all answer.

That widening also forced a second one-line edit, and it is evidence the first was measured rather than assumed: sql-driver-12732-varchar-emitter-parity-wiring.test.ts's FakePostgresDriver overrode dialectName as protected, and TypeScript refuses an override that narrows visibility. pnpm --filter @objectstack/driver-sql typecheck exited 2 on it (TS2415) before the override was made public override; it exits 0 after.

The three compilers

Per dialect: sqlite GLOB (one bound value, no ESCAPE clause, its own * / ? / [ escaped class), mysql LIKE over CAST(… AS BINARY), postgres LIKE unchanged, unknown LIKE unchanged.

The #14079 steer, reverted

text-operator-non-text-column.test.ts had steered one control comparand off the case axis (a.b instead of acme) with a comment saying why: a plain LIKE folds case on SQLite, so acme answered rows 1 AND 2 whether the disjunction worked or not. That suite now states its engine's dialect and the control is back on the case axis, answering row 2 alone — a strictly stronger control. Its unawareCtx deliberately keeps the dialect-blind configuration, because the coercion rows that file exists for were measured through it.

Verification

  • pnpm --filter @objectstack/service-analytics test91 files, 1998 tests, 0 failures (exit 0).
  • pnpm --filter @objectstack/service-analytics typecheck — exit 0; --listFiles confirms the new test and text-match-sql.ts are inside the program.
  • typecheck exit 0 on driver-sql, driver-sqlite-wasm, driver-turso; four targeted driver-sql suites (the wiring test plus the three text/case ones) — 111 passed, 2 skipped, exit 0.
  • pnpm lint repo-wide (eslint . --no-inline-config) — exit 0. Not narrowed.
  • Gate family re-derived from the real change set with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (54 families named, identical before and after the merge of origin/main). Run locally, every exit code captured by redirect before any pipe: check:nul-bytes, check:where-matcher, check:engine-double-contract, check:query-options-erasure, check:type-check-coverage, check:cross-package-test-inputs, check:test-source-alias, check:driver-conformance, check:changeset-gate-self-tests, check:objectui-changeset, check:pm-half-states, check:doc-authoring, check:published-files, check:objectql-double-limit, check:filter-alias-parity, check:dispatcher-error-vocabulary, check:type-source-resolution, check:logger-receiver-detach, check:partof-closing-keyword, check:single-claim-paths, check:error-status-conformance, check:error-code-casingall exit 0. scripts/check-adr-0087-registration.mjs exit 0 with its --self-test control also exit 0.
  • NOT MEASURED, stated as such: check:published-readme-exports exited 3 — its own output says nothing was measured, because ~37 packages have no built dist; check:type-check-debt likewise needs the whole workspace built. Both need a full-workspace build that CI's Build Core job performs. MySQL and live Postgres are NOT MEASURED: no server is provisionable in this container, so the CAST(… AS BINARY) arm and the Postgres non-regression are pinned as compiled TEXT, exactly the declared skip driver-sql's own drivers(sql family): 文本算子的大小写折叠是「方言的」而非「契约的」—— $contains 在 SQLite 过折叠、$icontains 在 PG/MySQL 过折叠 #6518 suite records.

Every new pin was mutated to confirm it discriminates. Each leg committed first, mutated, proved on disk (a marker grep plus a git hash-object change against the HEAD blob), run, then restored with git checkout HEAD -- PATH (absolute) under an EXIT INT TERM trap and proved restored (git diff HEAD empty and the blob hash back to the HEAD one). No build or dist is involved: the compilers reach the tests through relative source imports, so vitest reads the mutated source directly.

mutation expected observed
delete the sqlite GLOB arm the whole family reds on all three compilers 8 failures across both suites, incl. the read scope and the echo
escapeGlobPattern to identity the GLOB-metacharacter pins red 2 failures
widen the sqlite arm onto postgres/unknown the byte-identical non-regression pins red 2 failures
disable the mysql arm the text-only mysql pin reds 1 failure
collapse $icontains onto the case-exact table the fold control reds 1 failure

Out of scope, filed

#15780 — the same three compilers emit translate() for $icontains, a function SQLite does not have (measured on sql.js 1.14.1: no such function: translate), so that statement fails to parse rather than answering wrong rows. Deliberately not addressed here: this card's scope is the case-EXACT four, and this PR's suite pins the translate() text as the control that must stay unchanged. like-pattern.ts had predicted it word for word; it is now measured, and the dialect table this PR adds is where the remedy belongs.

Escaping (#5567) and the $icontains fold (#6520) are unchanged wherever a LIKE is still emitted, as the card required.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y


Generated by Claude Code

…ap; keep the fake driver's dialectName override public

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-sql, @objectstack/service-analytics, touching 28 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-analytics/src/like-pattern.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/permissions/tenant-audit-census.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via AnalyticsServicePlugin (symbol, a top-level class), SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx (via SqlDriver (symbol, a top-level class))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx (via generateSql (symbol, a method of class ObjectQLStrategy))
  • content/docs/releases/v17.mdx (via SqlDriver (symbol, a top-level class), generateSql (symbol, a method of class ObjectQLStrategy))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/services/service-analytics/src/like-pattern.ts) — pages documenting those are invisible to this run
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 901773b213b7848bdd60182db9afbdc55a7f9ad1packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3c5be1dad89e5773f8ae9d29e62949c9a9554936 — the merge of head dbf91caa81d1d8e7625f276870ec7dceaee4c492 into base 901773b213b7848bdd60182db9afbdc55a7f9ad1, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3c5be1dad89e5773f8ae9d29e62949c9a9554936 && git checkout 3c5be1dad89e5773f8ae9d29e62949c9a9554936
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 901773b213b7848bdd60182db9afbdc55a7f9ad1 dbf91caa81d1d8e7625f276870ec7dceaee4c492 && git checkout -B drift-repro 901773b213b7848bdd60182db9afbdc55a7f9ad1 && git merge --no-ff dbf91caa81d1d8e7625f276870ec7dceaee4c492

node scripts/docs-audit/affected-docs.mjs --json 901773b213b7848bdd60182db9afbdc55a7f9ad1

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 901773b213b7848bdd60182db9afbdc55a7f9ad1 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

Clause-② contract review — PR #15790 (card #15684) — PASS, with notes

Head dbf91caa8, merge-base c99449ab5, reviewed in a dedicated worktree detached at the head (tree clean at head after review). Tier: CONTRACT_REVIEW_TIER = 'claude-fable-5-1' (scripts/pm/dispatch-gates.mjs) — override + self-report (the dispatching call carried an explicit model: fable override; my own system-prompt identity is claude-fable-5-1). Each item below is tagged MEASURED (I drove it), READ (settled from source), or NOT MEASURED.

Already verified by the PM and not redone: head match, 13 files (+1010/−102), service-analytics/package.json absent from the diff, the driver-sql edit being visibility-only, ADR-0087 exit 0.

1. "No dialect-blind fix exists" — holds. MEASURED on sql.js 1.14.1 (SQLite 3.49.1) and better-sqlite3 13.0.3 (SQLite 3.53.4); MySQL/PG halves reasoned, NOT MEASURED

Over the shared nine-row fixture, contract contains 'acme'['2']:

candidate sql.js better-sqlite3
col LIKE '%acme%' (pre-fix) ['1','2'] ['1','2']
col GLOB '*acme*' (the fix) ['2'] ['2']
CAST(col AS BLOB) LIKE ? ['1','2'] — folds like plain LIKE []PRAGMA compile_options lists LIKE_DOESNT_MATCH_BLOBS
CAST(col AS BINARY) typeof = integer: 'ACME Corp'0, '100% match'100 (NUMERIC affinity, confirmed)
translate('ABC','ABC','abc') no such function: translate
col COLLATE BINARY LIKE ? / LIKE ? COLLATE BINARY ['1','2'] — LIKE ignores the collation
PRAGMA case_sensitive_like=ON flips every later LIKE on the connection (connection-global, as the header says)
instr/replace/substr/hex case-exact on SQLite

Finding (doc precision, not a design defect): the header's third bullet — "CAST(col AS BLOB) LIKE ? was measured … to return NOTHING — SQLite's LIKE is false for a BLOB operand" — is build-specific, not a SQLite fact. It is true on better-sqlite3 (driver-sql, turso), which sets SQLITE_LIKE_DOESNT_MATCH_BLOBS; on sql.js (driver-sqlite-wasm — the engine this package's own suite runs on) the same SQL folds case instead. The conclusion survives on both builds and is in fact stronger: the one construct means two different things across the two SQLite builds the platform ships. The replace() argument (no prefix/suffix without dialect-specific length arithmetic) I accept as stated; I add, reasoned only: PG has no instr()/hex(), MySQL's INSTR/=/<> follow the collation (and PAD SPACE makes replace(col,' ','') <> col false), and hex() substring search can false-positive on nibble misalignment. No portable case-exact construct survives.

2. Construct table arm for arm — matches. READ, both sources side by side

arm driver-sql textMatchPredicate (fold=false) service-analytics textMatchPredicateSql
sqlite ?? [NOT ]GLOB ?, pattern wrap(escapeGlobComparand(v), '*'), one value bound ${col} [NOT ]GLOB ${bind(globPattern)}, wrapShape(escapeGlobPattern(v), '*'), one value, no ESCAPE
postgres ?? [NOT ]LIKE ? ESCAPE ? ${col} [NOT ]LIKE ${bind(likePattern)} ESCAPE ${bind('\\')}
mysql CAST(?? AS BINARY) [NOT ]LIKE CAST(? AS BINARY) ESCAPE ? CAST(${col} AS BINARY) [NOT ]LIKE CAST(${bind} AS BINARY) ESCAPE ${bind('\\')}
unknown ?? [NOT ]LIKE ? ESCAPE ? same as postgres arm

Escape classes: escapeGlobComparand and escapeGlobPattern are both /[*?[]/g → '[$&]'; escapeLikeComparand and escapeLikePattern are both /[\\%_]/g → '\\$&'; wildcard placement (wrapTextMatchShape / wrapShape, likePattern) identical; negation keywords identical; the four dialect names identical (SqlDialectName = 'sqlite'|'postgres'|'mysql'|'unknown', per the PM). The only structural difference is the fold row (lower(??) GLOB lower(?) / LOWER() on the driver vs translate() here), which is #15780 and pre-existing.

Two notes on the anti-drift claim, neither blocking: (a) the executed SqliteWasmDriver cross-check holds only the sqlite arm mechanically — all sql.js can run; the mysql and postgres arms are held by two independent text pins (driver conformance via knex toString(), analytics via string equality) that match today by my reading, not by any single assertion. A shared shape fixture would make that mechanical. (b) The new test header says this is "the one like-pattern.ts already uses" — the precedent (like-metacharacter-escape.test.ts) is a mirrored regex in a test, not an executed driver cross-check; this PR's mechanism is the stronger one, the header just overstates the precedent. The PR body's phrasing ("a test, not a comment") is the accurate one.

3. GLOB escaping completeness — complete. READ + MEASURED

Why ], ^, - need no escape: they act only inside a class, every author [ becomes the self-closing class [[], so no author-opened class survives for them to act in, and a stray ] outside a class is literal. Driven on sql.js: 46 comparands × 3 shapes = 138 checks against a JS includes/startsWith/endsWith oracle — including ], ^, -, [], [^], [^a], [a-z], [[], ]], a[b]c, \, \\, %, _, é/É, 日本語, and the empty string — 0 mismatches, 0 errors. The dev's unescaped controls reproduce exactly: *a*b* → 6 rows, *a?b* → 5, *a[b* → 0 (an unclosed class selects nothing; it does not fail to parse). Edge parity with LIKE: numeric columns coerce identically, ? is one UTF-8 character, NOT GLOB on NULL is NULL, the empty comparand matches every non-NULL row under both. The sqlite arm binds one value and emits no ESCAPE — read in text-match-sql.ts and pinned (params: ['*acme*']).

4. Non-regression controls — hold by READING; executed A/B NOT MEASURED

By reading, the postgres/unknown arm (${col} ${LIKE|NOT LIKE} ${bind(pattern)} ESCAPE ${bind('\\')}) reproduces the pre-fix ${rawCol} ${sqlOp} ${patternRef} ESCAPE $N with the same push order; the read scope's textMatch is the old bindLike template verbatim; the echo's non-fold row prints the old ${col} LIKE $1 ESCAPE $2 / NOT LIKE. $icontains is untouched on all three faces (the pushes moved inside the branch, same order). normalizeSqlDialect sends '', 'SQLite', 'mssql', null, undefined to 'unknown' (pinned). The pre-fix defect through a no-hook host (['1','2']) is pinned and agrees with my LIKE measurement. I built an executed A/B (merge-base compilers vs head: 5 ops × 10 comparands × 4 filter shapes × 7 dialect values × 4 faces) but the run was killed by its time-box on this contended box — NOT MEASURED. The PR's own verbatim pins for native + read scope on postgres and no-hook ran green on CI.

5. All three compilers — covered. READ

NativeSQLStrategy.buildFilterClause (native where); applyReadScopecompileScopedFilterToSql(…, { dialect: sqlDialectFor(ctx, objectName) }); the ObjectQLStrategy echo fills the same option on its scope and reaches textMatchPredicateSql through renderFilterNodeSql, which passes target and ctx. The echo's execution is real: the test runs run(echo.sql, echo.params) on sql.js and asserts echo.params equal native.params and the same ids. The #14079 non-text gate still precedes binding (?? short-circuit).

6. Visibility widening — READ

protected get dialectNamepublic get, body unchanged. SqliteWasmDriver overrides only isSqlite; TursoDriver configures client: 'better-sqlite3' (in SQLITE_EMIT_CLIENTS), so all three answer. FakePostgresDriver's public override is what TS's no-narrowing-override rule (TS2415) requires; the reversion experiment itself I did not run (deprioritised; CI "Type Check · workspace" is green on the head).

7. Honesty audit — MEASURED/READ

check:published-readme-exports and check:type-check-debt both exit 3 = PREREQUISITE NOT MET in my worktree; the PR body lists them under "NOT MEASURED, stated as such", not as passes. The precedent: driver-sql's #6518 suite declares the MySQL cell a skip (declareUnprovisionedCell, it.skipIf(!OS_EXPECT_LIVE_DIALECT_MATRIX)) — but it ran live Postgres (16.13). So "the same skip the precedent took" is exact for MySQL and not for Postgres; the Postgres non-regression here rests on byte-identity instead, which is the right basis since those bytes did not change. Two precision notes: the analytics MySQL cell's NOT MEASURED lives only in a test name (no machine-visible skip), and CI does provision live PG + MySQL (Temporal Conformance (live PG + MySQL), OS_EXPECT_LIVE_DIALECT_MATRIX=1), so the driver's CAST(… AS BINARY) construct is live-measured one layer down — a follow-up could run the analytics statements in that job.

8. #15780 — READ

Exists, open, labels pm:queue only, unassigned. This PR leaves asciiLowerSqlExpr untouched (the like-pattern.ts diff is comment-only), $icontains still emits translate() on all three compilers, and the new suite pins that text across all four dialect values. translate()no such function re-measured on sql.js. Agreed it is plausibly more severe than this card (parse failure vs wrong rows); routing is the PM's.

9. Structural vs contract member — a read, not a decision

The dev's reasoning does not hold: optional IDataDriver members are exactly how the contract models family-specific capability — temporalFilterValue/temporalFilterColumnSql are that (ADR-0053 D-A2: "absent = identity" for memory/mongo), so a dialectName? that memory/mongo omit would not "declare a capability the platform lacks". The dev's recommendation does hold, on a different ground: the #11833/#12248 evidence bar promoted getDriverForObject only once three consumers existed, and D-A2's own history shows a tracked structural read as the accepted interim. The two seams differ by evidence count, not by kind; the typeof guard plus normalizeSqlDialect make the read safe today. Non-blocking ask: record the promotion trigger somewhere findable (the plugin comment or a note on #15684) so the interim seam is actually tracked, as D-A2's was.

Verdict

PASS. Nothing here makes a published statement false or the fix wrong; query-syntax.mdx already says the four operators are case-sensitive, and this PR makes that true on analytics/SQLite. Notes, none blocking: (N1) qualify the BLOB bullet in text-match-sql.ts with SQLITE_LIKE_DOESNT_MATCH_BLOBS; (N2) the test header's precedent wording; (N3) a machine-visible MySQL skip or CI live-job wiring; (N4) a findable promotion trigger for dialectName.

NOT MEASURED by this review: the executed A/B byte-identity matrix and the local service-analytics / driver-sql suite runs (contended box; relied on CI — Build Core, Test Core 2–6, all Type Check and Lint jobs green on dbf91caa8, Test Core 1/6 still in progress at posting); the TS2415 reversion; MySQL and live Postgres.


Reviewer: clause-② contract review, domain:services seat (PM session 03324ae2-0f5b-5ad2-8a2e-cf4aaff5a909) · tier CONTRACT_REVIEW_TIER = claude-fable-5-1 — override + self-report · head dbf91caa8.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 5, 2026 08:38
@os-warren
os-warren enabled auto-merge September 5, 2026 08:38

Copy link
Copy Markdown
Collaborator Author

PM note on landing — two claims in this PR are narrower than they read

Posted by the domain:services PM seat. ⛔ Neither is blocking and neither changes behaviour; both are recorded because they are claims a future reader will rely on. From the Clause-② review (comment 5550634196), verdict PASS.

1. The CAST(col AS BLOB) LIKE claim is build-specific, not a universal SQLite fact

text-match-sql.ts's header states that CAST(col AS BLOB) LIKE returns nothing on SQLite. Measured on two SQLite builds:

build CAST(col AS BLOB) LIKE '%acme%'
better-sqlite3 13.0.3 (SQLite 3.53.4) [] — because it compiles with SQLITE_LIKE_DOESNT_MATCH_BLOBS
sql.js 1.14.1 (SQLite 3.49.1) ['1','2'] — the folded rows, i.e. exactly the defect

⇒ The rejection of that candidate still holds, and for a stronger reason than the header gives: one construct with two different meanings across the two SQLite builds this repo ships is disqualifying on its own. Only the "returns nothing" phrasing is too broad.

⚠️ The same sentence exists one layer down, in driver-sql's #6518 header — this PR inherited it rather than introducing it. Filed as a docs-precision card so a future editor of either file inherits the measurement instead of rediscovering it.

2. The anti-drift precedent is overstated in the new test's header

The new suite's header attributes an executed-driver cross-check precedent to like-metacharacter-escape.test.ts. That test is a mirrored regex in a test, not an executed driver cross-check. ⇒ The PR body's wording ("a test, not a comment") is accurate; only the test header overstates it. The cross-check this PR actually performs — against a real SqliteWasmDriver — is genuine, and it mechanically holds only the sqlite arm; the mysql and postgres arms are held by two independent text pins, which match by reading today.

3. A correction to this PR's own NOT-MEASURED justification

The PR justifies not measuring MySQL/Postgres as "the same declared skip driver-sql's own #6518 suite records". That precedent is MySQL only — driver-sql's #6518 suite ran live Postgres 16.13. ⇒ The Postgres non-regression here rests instead on byte-identity (the postgres and no-hook arms emitting byte-identical SQL and params to pre-fix), which the review judged the right basis. ⚠️ Separately noted: the analytics MySQL cell has no machine-visible skip, so nothing in CI records that it went unmeasured.

4. On the architectural question — right recommendation, wrong reason

The dev argued the dialect should stay a structural read because a dialect is a property of the SqlDriver family, not of every driver, and a contract member the memory/mongo drivers could only answer undefined to would declare a capability the platform lacks. ⇒ That reasoning does not hold: an optional IDataDriver member is precisely how family-specific capability is already modelled here — temporalFilterColumnSql is exactly that, a contract member since ADR-0053 D-A2.

The recommendation (leave it structural for now) nonetheless stands, on a different basis: the #11833 / #12248 evidence bar for promoting a seam, and D-A2's own tracked-interim history. ⇒ Non-blocking ask for whoever owns this next: record the promotion trigger somewhere findable, so "when a second consumer appears" is a condition someone will actually notice rather than a sentence in a PR.

⭐ Worth stating plainly, since it was the highest risk I flagged into this review: GLOB escaping was proven complete, not reasoned — 46 comparands × 3 shapes = 138 oracle checks, 0 mismatches, 0 errors, with the unescaped controls reproducing (*a*b*→6 rows, *a?b*→5, *a[b*→0). And the construct table does match driver-sql's arm for arm, read side by side.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants